Spring으로 3 Tier Architecture 구조 만들기(MyBatis)

✒️ 2025-06-23 13:46 내용 수정


주문 추가 및 조회하는 페이지 만들기

1. DB 연결 및 테이블 구성

  1. DB 연결을 위한 Mybatis 설정을 참고하여 DB 연결에 필요한 MyBatis 설정을 진행한다.
  2. DB에 상품 테이블과 주문 테이블을 생성한다.
-- 상품 시퀀스
CREATE SEQUENCE SEQ_PRODUCT;

-- 상품 테이블
CREATE TABLE PRODUCT(
	PRODUCT_ID NUMBER PRIMARY KEY,
	PRODUCT_NAME VARCHAR2(500) NOT NULL,
	PRODUCT_STOCK NUMBER DEFAULT 0,
	PRODUCT_PRICE NUMBER DEFAULT 0,
	REGISTER_DATE DATE DEFAULT SYSDATE,
	UPDATE_DATE DATE DEFAULT SYSDATE
);

-- 주문 시퀀스
CREATE SEQUENCE SEQ_ORDER;

-- 주문 테이블
CREATE TABLE "ORDER"(
	ORDER_ID NUMBER PRIMARY KEY,
	PRODUCT_ID NUMBER NOT NULL,
	PRODUCT_COUNT NUMBER DEFAULT 1,
	ORDER_DATE DATE DEFAULT SYSDATE,
	CONSTRAINT FK_ORDER_PRODUCT FOREIGN KEY(PRODUCT_ID) REFERENCES PRODUCT(PRODUCT_ID)
);

2. 전체 코드 흐름

spring boot tier projectfile.png

3. DTO와 Mapper 인터페이스, mapper.xml 생성

  1. src/main/java 폴더의 com.example.tier처럼 group id와 artifact로 된 패키지의 하위 패키지로 dto 패키지와 mapper 패키지를 만든다.
  2. dto 패키지에 ProductDTO와 OrderDTO를 만든다.
package com.example.tier.dto;

import lombok.Data;

@Data
public class ProductDTO {
	private int productId;
	private String productName;
	private int productStock;
	private int productPrice;
	private String registerDate;
	private String updateDate;
}
package com.example.tier.dto;

import lombok.Data;

@Data
public class OrderDTO {
	private int orderId;
	private int productId;
	private int productCount;
	private String orderDate;
}
  1. FK관계로 이어진 Product 테이블과 Order 테이블의 정보를 모두 담을 수 있는 OrderVO 클래스를 vo 패키지에 만든다.
    • 데이터 객체에서 DTO와 VO는 다른 항목이다.
    • 편의상 Lombok의 @Data를 사용해 모든 getter와 setter를 추가했지만 데이터를 수정하기보단 데이터를 가져오는데에 더 특화된 클래스다.
package com.example.tier.vo;

import lombok.Data;

@Data
public class OrderVO {
	private int productId;
	private String productName;
	private int productStock;
	private int productPrice;
	private String registerDate;
	private String updateDate;
	
	private int orderId;
	private int productCount;
	private String orderDate;
	private int orderPrice; // DB에서 PRODUCT_PRICE와 PRODUCT_COUNT로 만들 예정
}
  1. mapper 패키지에 ProductMapper와 OrderMapper를 인터페이스로 만든다.
    • Mapper 클래스에는 @Mapper Annotation을 추가한다.
    • 인터페이스로 만드는 이유는 확장성을 위해서다.
    • Mapper에 작성된 메소드 이름은 SQL문의 id와 동일해야 하며, 이는 Spring에서 DAO에서 sqlSession.selectAll("a.select")로 작성했던 "select"가 mapper.xml에 있는 SQL문의 id인 것과 같다.
    • Mapper 인터페이스를 분리하는 이유에 대해 강사님께 질문했을 때 Spring boot에선 점점 mapper와 DAO를 분리하는 것이 추세라고 하셨다.
package com.example.tier.mapper;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;

import com.example.tier.dto.OrderDTO;
import com.example.tier.dto.ProductDTO;

@Mapper
public interface ProductMapper {

	// 상품 추가
	// INSERT INTO PRODUCT VALUES(?,?,?,?,?)
	public void insert(ProductDTO productDTO);
	
	// 상품 조회
	public List<ProductDTO> selectAll();
	
	// 상품 재고 수정
	public void updateStock(OrderDTO orderDTO);
}
package com.example.tier.mapper;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;

import com.example.tier.dto.OrderDTO;
import com.example.tier.vo.OrderVO;

@Mapper
public interface OrderMapper {

	// 주문 추가
	public void insert(OrderDTO orderDTO);
	
	// 주문 조회
	public List<OrderVO> selectAll(String sort);
}
  1. src/main/resources 폴더에 mapper 패키지를 만들고, Mapper 인터페이스와 연결할 mapper.xml(product.xml과 order.xml)의 SQL문을 작성한다.
    • SELECT 시 컬럼을 전체 조회할 때 SELECT * FROM TABLENAME으로 작성하는 대신 SELECT ID, NAME FROM TABLENAME처럼 컬럼명을 모두 작성하는 것이 조회 속도가 더 빠르다.
    • mapper의 namespace는 Mapper 인터페이스의 패키지 이름을 포함한 전체 이름으로 작성해야 한다.
    • Spring boot에선 parameterType과 resultType을 생략해도 작동한다.
<?xml version="1.0" encoding="UTF-8"?>
	  
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0/EN" "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <typeAliases>
		<typeAlias type="com.example.tier.dto.ProductDTO" alias="productDTO"/>
		<typeAlias type="com.example.tier.dto.OrderDTO" alias="orderDTO"/>
    	<typeAlias type="com.example.tier.vo.OrderVO" alias="orderVO"/>
    </typeAliases>
</configuration>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.tier.mapper.ProductMapper">
	<insert id="insert">
		INSERT INTO PRODUCT (
			PRODUCT_ID,
			PRODUCT_NAME,
			PRODUCT_STOCK,
			PRODUCT_PRICE
		) VALUES(
			SEQ_PRODUCT.nextVal,
			#{productName},
			#{productStock},
			#{productPrice}
		)
	</insert>
	
	<select id="selectAll">
		<!-- column 이름을 명시하는게 조회 시 더 빠르다 -->
		SELECT PRODUCT_ID, PRODUCT_NAME, PRODUCT_STOCK, 
				PRODUCT_PRICE, REGISTER_DATE, UPDATE_DATE 
		FROM PRODUCT
	</select>
	
	<!-- 주문이 들어오면 자동으로 재고량을 차감 -->
	<update id="updateStock">
		UPDATE PRODUCT
		SET PRODUCT_STOCK = PRODUCT_STOCK - #{productCount}
		WHERE PRODUCT_ID = #{productId}
	</update>
</mapper>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.tier.mapper.OrderMapper">
	<insert id="insert">
		INSERT INTO "ORDER"(
			ORDER_ID,
			PRODUCT_ID,
			PRODUCT_COUNT
		) VALUES(
			SEQ_ORDER.nextVal,
			#{productId},
			#{productCount}
		)
	</insert>
	
	<!-- mybatis 동적 쿼리문을 사용해 특정 분기에 따라 유동적으로 처리하는 sql문을 작성 -->
	<select id="selectAll" resultType="orderVO">
		<!-- column 이름을 명시하는게 조회 시 더 빠르다 -->
		SELECT P.PRODUCT_NAME, P.PRODUCT_STOCK, P.PRODUCT_PRICE, P.REGISTER_DATE, P.UPDATE_DATE, 
			O.ORDER_ID, O.PRODUCT_COUNT, O.ORDER_DATE, (P.PRODUCT_PRICE * O.PRODUCT_COUNT) AS ORDER_PRICE
		FROM PRODUCT P JOIN "ORDER" O 
		ON P.PRODUCT_ID = O.PRODUCT_ID
		<choose>
			<when test="sort == 'recent'.toString()">
				ORDER BY O.ORDER_ID DESC
			</when>
			<otherwise>
				ORDER BY ORDER_PRICE DESC
			</otherwise>
		</choose>
	</select>

</mapper>

4. DAO와 Service

  1. 이제 src/main/java의 하위 dao패키지를 만들어 Mapper 인터페이스를 사용할 ProductDAO와 OrderDAO를 만든다.
    • DAO 클래스에는 @Repository Annotation을 추가한다.
    • DAO는 Mapper 인터페이스를 생성자 주입하고, Mapper 인터페이스의 메소드를 호출한다.
package com.example.tier.dao;

import java.util.List;

import org.springframework.stereotype.Repository;

import com.example.tier.dto.OrderDTO;
import com.example.tier.dto.ProductDTO;
import com.example.tier.mapper.ProductMapper;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class ProductDAO {

	private final ProductMapper productMapper;
	
	// 상품 추가
	public void save(ProductDTO productDTO) {
		productMapper.insert(productDTO);
	}
	
	// 상품 조회
	public List<ProductDTO> findAll() {
		return productMapper.selectAll();
	}
	
	// 상품 재고 수정
	public void setProductStock(OrderDTO orderDTO) {
		productMapper.updateStock(orderDTO);
	}
}
package com.example.tier.dao;

import java.util.List;

import org.springframework.stereotype.Repository;

import com.example.tier.dto.OrderDTO;
import com.example.tier.mapper.OrderMapper;
import com.example.tier.vo.OrderVO;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class OrderDAO {

	private final OrderMapper orderMapper;
	
	// 주문 추가
	public void save(OrderDTO orderDTO) {
		orderMapper.insert(orderDTO);
	}
	
	// 주문 내역
	public List<OrderVO> findAll(String sort) {
		return orderMapper.selectAll(sort);
	}
}
  1. 서비스 기능을 처리할 Service 인터페이스와 그 구현 클래스들을 만든다.
    • Service 구현 클래스에는 @Service Annotation을 추가한다.
    • Service 구현 클래스에선 DAO를 생성자 주입하고, DAO의 메소드를 호출하여 서비스 기능을 수행한다.
package com.example.tier.service;

import java.util.List;

import com.example.tier.dto.ProductDTO;

public interface ProductService {
	
	// 상품 추가
	public void register(ProductDTO productDTO);
	
	// 상품 조회
	public List<ProductDTO> getList();
}
package com.example.tier.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.example.tier.dao.ProductDAO;
import com.example.tier.dto.ProductDTO;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService{

	final ProductDAO productDAO;
	
	@Override
	public void register(ProductDTO productDTO) {
		productDAO.save(productDTO);
	}
	
	@Override
	public List<ProductDTO> getList() {
		return productDAO.findAll();
	}
}
package com.example.tier.service;

import java.util.List;

import com.example.tier.dto.OrderDTO;
import com.example.tier.vo.OrderVO;

public interface OrderService {
	// 인터페이스로 만드는 이유
	// FoodOrder, ToolOrder, ClotheOrder 등 여러 상품에 대한 Order를 구현하기 위함
	
	// 주문 추가
	public void order(OrderDTO orderDTO);
	
	// 주문 조회
	public List<OrderVO> getList(String sort);
}
package com.example.tier.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.example.tier.dao.OrderDAO;
import com.example.tier.dao.ProductDAO;
import com.example.tier.dto.OrderDTO;
import com.example.tier.vo.OrderVO;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService{

	private final OrderDAO orderDAO;
	private final ProductDAO productDAO;
	
	// 주문하기
	@Override
	public void order(OrderDTO orderDTO) {
		productDAO.setProductStock(orderDTO); // 재고량을 차감한다
		orderDAO.save(orderDTO); // 주문을 저장한다
	}

	@Override
	public List<OrderVO> getList(String sort) {
		return orderDAO.findAll(sort);
	}
}

5. Controller

  1. src/main/java의 하위 패키지로 controller 패키지를 만들고, ProductController와 OrderController 클래스를 만든다.
    • Controller 클래스는 @Controller Annotation과 @RequestMapping("/상위경로") Annotation(선택사항)을 추가한다.
    • @RequestMapping()이 없다면 URI에 "/매핑이름"만 입력해도 되지만, 컨트롤러가 여러 개 존재하거나 항목별로 구분하고 싶다면 "/상위경로/매핑이름" 형태로 작성하기 위해 Annotation을 추가해도 된다.
    • RedirectView : 절대 경로, context 상대경로, 또는 현재 요청의 상대 URL로 redirect하는 view 클래스
      • Spring에서 return "redirect:경로명는 String을 반환하는 리다이렉션 형태고, RedirectView는 클래스 인스턴스를 사용한 리다이렉션 형태다.
    • HTML을 반환할 경우 파일의 src/main/resources/template 내의 경로와 파일 이름까지만 작성한다.
package com.example.tier.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.view.RedirectView;

import com.example.tier.dto.ProductDTO;
import com.example.tier.service.ProductService;

import lombok.RequiredArgsConstructor;

@Controller
@RequestMapping("/product/*")
@RequiredArgsConstructor
public class ProductController {

	// 서비스 하나 당 query문 1개라면 Mapper를 직접 주입했으나
	// Mapper를 여러 개 주입하기보다는 Service를 주입해서 해결한다
	private final ProductService productService; // ProductServiceImpl 객체가 주입됨
	 
	@GetMapping("register") // "/product/register"로 요청
	public String register(Model model) {
		model.addAttribute("productDTO", new ProductDTO()); // model을 사용하여 페이지에 데이터 포워딩
		return "/product/product-insert"; // html의 경로와 이름까지만 작성
	}
	
	@PostMapping("register")
	public RedirectView register(ProductDTO productDTO) {
		productService.register(productDTO);
		return new RedirectView("list");
	}
	
	@GetMapping(value={"/", "list"})
	public String list(Model model) {
		model.addAttribute("list", productService.getList());
		return "/product/product-list";
	}
}
package com.example.tier.controller;

import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.view.RedirectView;

import com.example.tier.dto.OrderDTO;
import com.example.tier.service.OrderService;
import com.example.tier.vo.OrderVO;

import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
@RequestMapping("/order/*")
public class OrderController {

	private final OrderService orderService;
	
	@GetMapping("done")
	public RedirectView order(OrderDTO orderDTO) {
		System.out.println("주문 개수 : " + orderDTO.getProductCount());
		orderService.order(orderDTO);
		
		return new RedirectView("/product/list");
	}
	
	@GetMapping("list")
	public String list(Model model, @RequestParam(required=false, defaultValue="recent") String sort) {
//		if (sort==null) { // 이렇게 작성해도 된다
//			sort="recent";
//		}

		List<OrderVO> list = orderService.getList(sort); // DB에 sort를 넘겨주어 정렬 방법을 설정한다
		model.addAttribute("orders", list);
		model.addAttribute("sort", sort);
		return "order/order-list";
	}
}

6. HTML

  1. 이제 src/main/resources/templates 패키지 하위에 product 폴더와 order 폴더를 만들고, 정보를 주고받을 HTML을 만든다.
    • 실습에서 script에 JQuery를 사용했는데, 아직 JQuery에 대해 공부하지 못해 나중에 상세 내용을 추가할 예정이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>상품 목록</title>
	<style>
		div{margin:0 auto; width:1000px;}
		table{width:100%; text-align:center;}
		button{width:50%;}
	</style>
</head>
<body>
	<a th:href="@{/product/register}">상품 추가하기</a>
	<div class="container">
		<table border="1">
			<tr>
				<th>단일 선택</th>
				<th>주문 개수</th>
				<th>번호</th>
				<th>이름</th>
				<th>재고</th>
				<th>가격</th>
				<th>등록 날짜</th>
				<th>수정 날짜</th>
			</tr>
			<th:block th:each="product : ${list}">
			<tr th:object="${product}">
				<td><input type="radio" name="productId" th:value="*{productId}"></td>
				<td><input type="text" class="productCount" readOnly></td>
				<td th:text="*{productId}"></td>
				<td th:text="*{productName}"></td>
				<td th:text="*{productStock}"></td>
				<td th:text="*{productPrice}"></td>
				<td th:text="*{registerDate}"></td>
				<td th:text="*{updateDate}"></td>
			</tr>
			</th:block>
		</table>
		<button type="button" id="order-done">주문완료</button><button type="button" onclick="location.href='/order/list'">주문내역</button>
	</div>
	<form th:action="@{/order/done}" method="get" name="order-form">
		<input type="hidden" name="productId">
		<input type="hidden" name="productCount">
	</form>
	
	<!-- JQuery 사용 -->
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
	<script>
		const $radios = $("input[type='radio']");
		const $inputs = $("input[class='productCount']");
		const $done = $("#order-done");
		const $form = $("form[name='order-form']");
		let $temp, i; 

		$radios.on("click", function() {
			i = $radios.index(this); // 변수 i에 선택한 라디오 버튼의 index값 저장
			/* console.log(i) */
 			if($temp) {
				$temp.prop("readOnly", true);
				$temp.value = "";
			}
			
			// input 태그가 i번인 태그를 선택하고, readOnly를 false로 설정
			$inputs.eq(i).prop("readOnly", false); 
			// $temp에 선택된 input 태그를 저장
			$temp = $inputs.eq(i);
		});
		
		$done.on("click", function() {
			if(i+1) {
				console.log('누른 라디오버튼 : ', i);
				// form 태그에서 name이 productId인 input 태그에다가 radio 버튼의 제품 번호를 넣음
				$form.find("input[name='productId']").val($radios.eq(i).val());
				// form 태그에서 name이 productCount인 input 태그에다가 input의 제품 개수를 넣음
				$form.find("input[name='productCount']").val($inputs.eq(i).val());
				//console.log($form.find("input[name='productId']").val());
				//console.log($form.find("input[name='productCount']").val());
				$form.submit();
			}
		})
	</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>상품 추가</title>
</head>
<body>
	<div>
		<form name="f" th:action="@{/product/register}" th:object="${productDTO}" method="post">
			<table>
				<tr>
				<!-- th:field : id, name, value 속성을 자동으로 처리함 -->
					<th>이름</th>
					<td><input th:type="text" th:field="*{productName}" placeholder="상품 이름"></td>
				</tr>
				<tr>
					<th>수량</th>
					<td><input th:type="text" th:field="*{productStock}" placeholder="재고량"></td>
				</tr>
				<tr>
					<th>가격</th>
					<td><input th:type="text" th:field="*{productPrice}" placeholder="상품 가격"></td>
				</tr>
			</table>
			<button type="submit">등록</button>
			<!-- a태그의 href는 th:href=@{"경로명"}으로 설정한다. -->
			<a th:href="@{/product/list}">취소</a>
		</form>
	</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
	<style>
		#container{margin:0 auto; width:1000px;}
		span{cursor:pointer;}
		span.on{font-weight:bold;}
		table{width:100%; border:1px soild black;}
		button{width:100%;}
	</style>
</head>
<body>
	<div id="container">
		<div class="sort">
			<span class="on" id="recent" data-sort="recent">최신순</span>
			<span class="" id="price" data-sort="price">결제 금액순</span>
		</div>
		<table>
			<tr>
				<th>상품 이름</th>
				<th>상품 가격</th>
				<th>주문 개수</th>
				<th>결제 금액</th>
				<th>주문 날짜</th>
			</tr>
			<th:block th:each="order : ${orders}">
				<tr th:object="${order}">
					<td th:text="*{productName}"></td>
					<td th:text="*{productPrice}"></td>
					<td th:text="*{productCount}"></td>
					<td th:text="*{orderPrice}"></td>
					<td th:text="*{orderDate}"></td>
				</tr>
			</th:block>
		</table>		
		<button type="button" onclick="location.href='/product/list'">상품목록</button>	
	</div>
	
	<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>

	<script th:inline="javascript">
		let sort = [[${sort}]];
		const $spans = $('div.sort span');
		
		// span의 class를 전부 비우고, sort id를 가진 span 태그의 class 값을 on으로 변경
		$("span").attr("class", "");
		$("span#"+sort).attr("class", "on");
		
		$spans.on("click", function() {
			location.href = `/order/list?sort=${$(this).data("sort")}`;
		});
	</script>	
</body>
</html>

spring boot tier 1.png

spring boot tier 2.png
spring boot tier 3.png

spring boot tier 4.png

spring boot tier 5.png
spring boot tier 6.png

spring boot tier 7.png

spring boot tier 8.png